# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""Facilities for processing a command-line and dispatching subcommands

Classes:
Option        -- representation of an option
OptionParser  -- customized optparse.OptionParser
Command       -- representation of a subcommand
TextDeltaProgressBase   -- base class for text delta progress reporters
TextDeltaProgressDots   -- prints dots, like svn
TextDeltaProgressMeter  -- shows a progress meter

Variables:
SvnOptions    -- map of option names to Option objects for svn
GvnOptions    -- map of option names to Option objects for gvn
CommandNames  -- list of command names (no aliases)
NameToCommand -- map of command names (including aliases) to Command objects

Functions:
FindProject   -- Find which project to use.
AuthOptions   -- Return the standard auth options.
LogOptions    -- Return the standard log options.
AddCommand    -- Add a new Command to the global list.
RunSvnCommand -- Run an svn command.
Run           -- Run a subcommand.
Notify        -- notify callback
main          -- main function

Call AddCommand with a function implementing a subcommand and some
metadata about it (see the Command class's documentation for details)
for each subcommand.  Call main with sys.argv; it uses OptionParser to
parse the command-line arguments and Run to dispatch the subcommands.

Run passes a Config object, an optparse.Values object, a list of
subcommand name + operands, and a svn.core.Pool.  Use the
optparse.Values object to test for options.  The operands are the
non-option arguments from the command line, without interpretation.

"""


import codecs
import optparse
import os
import sys
import time
import traceback

from errno import EPIPE

import svn.core
import svn.wc

from svn.core import svn_node_file
from svn.core import svn_path_internal_style, svn_path_local_style

import gvn.config
import gvn.errors
import gvn.platform
import gvn.project
import gvn.svncmdline
import gvn.svnauth
import gvn.svncmd
import gvn.util
import gvn.wc


class Option(object):
  def __init__(self, short_option, has_argument, help):
    self.short = short_option
    self.has_argument = has_argument
    self.help = help

SvnOptions = dict((k, Option(*v))
                  for k,v in gvn.svncmdline.Options.iteritems())
# Get specific about svn options that gvn subcommands take.
SvnOptions.update({
  # options all subcommands accept
  'config-dir': Option(None, True,
                       'read user configuration files from directory ARG'),
  'force': Option(None, False, 'force operation to run'),
  'non-interactive': Option(None, False, 'do no interactive prompting'),
  'quiet': Option('q', False, 'print nothing, or only summary information'),
  'verbose': Option('v', False, 'print extra information'),
  # auth options
  'no-auth-cache': Option(None, False, 'do not cache authentication tokens'),
  'username': Option(None, True, 'specify a username ARG'),
  'password': Option(None, True, 'specify a password ARG'),

  # options about getting user input
  'editor-cmd': Option(None, True, 'use ARG as external editor'),
  'encoding': Option(None, True,
                     'treat value as being in charset encoding ARG'),
  'file': Option('F', True, 'read log message from file ARG'),
  'message': Option('m', True, 'specify log message ARG'),

  # diff
  'diff-cmd': Option(None, True, 'use ARG as diff command'),
  'extensions': Option('x', True,
                          """Default: '-u'. When Subversion is invoking an
                             external diff program, ARG is simply passed along
                             to the program. But when Subversion is using its
                             default internal diff implementation, or when
                             Subversion is displaying blame annotations, ARG
                             could be any of the following:
                                -u (--unified):
                                   Output 3 lines of unified context.
                                -b (--ignore-space-change):
                                   Ignore changes in the amount of white space.
                                -w (--ignore-all-space):
                                   Ignore all white space.
                                --ignore-eol-style:
                                   Ignore changes in EOL style"""),
  'no-diff-deleted': Option(None, False, 'do not print differences for deleted files'),
  'notice-ancestry': Option(None, False, 'notice ancestry when calculating differences'),
  'old': Option(None, True, 'use ARG as the older target'),
  'new': Option(None, True, 'use ARG as the newer target'),
  'summarize': Option(None, False, 'show a summary of the results'),

  # depth
  'depth': Option(None, True,
                          """pass depth ('empty', 'files', 'immediates', or
                            'infinity') as ARG"""),
  'non-recursive': Option('N', False,
                    'obsolete; try --depth=files or --depth=immediates'),
  'recursive': Option('R', False,
                      'descend recursively, same as --depth=infinity'),

  'change': Option('c', True,
                          """the change made by revision ARG (like -r ARG-1:ARG)'),
                             If ARG is negative this is like -r ARG:ARG-1"""),
  'revision': Option('r', True,
                          """ARG (some commands also take ARG1:ARG2 range)
                             A revision argument can be one of:
                                NUMBER       revision number
                                '{' DATE '}' revision at start of the date
                                'HEAD'       latest in repository
                                'BASE'       base rev of item's working copy
                                'COMMITTED'  last commit at or before BASE
                                'PREV'       revision just before COMMITTED"""),
  'targets': Option(None, True, 'pass contents of file ARG as additional args'),
  'force-log': Option(None, False, 'force validity of log message source'),
})

# These options exist only for gvn.
GvnOptions = {
  # special options
  'diag': Option(None, False, 'show diagnostics about what gvn is doing'),
  'help': Option('h', False, 'help'),
  'version': Option(None, False, 'show program version information'),

  # options all subcommands accept
  'gvn-config-dir': Option(None, True,
                       'read user gvn configuration files from directory ARG'),
  'project': Option(None, True, 'use project ARG'),

  # gvn change
  'add': Option(None, False, 'add to changebranch'),
  'delete': Option(None, False, 'delete entire changebranch'),
  'remove': Option(None, False, 'remove changebranch association'),

  # gvn changes
  'all': Option(None, False, 'show all'),
  'user': Option(None, True, 'operate on USER (does not affect auth)'),

  # gvn diff
  'from-change': Option(None, False,
                        'show diff from changebranch to working copy'),

  # gvn mail
  'cc': Option(None, True, 'comma-separated list of addresses to CC'),
  'reviewers': Option(None, True,
                      'comma-separated list of reviewer addresses'),

  # commands that snapshot (change, mail, snapshot)
  'force-change': Option(None,  False, 'force validity of changebranch name'),
}


# list of command names (no aliases)
CommandNames = []
# map of command names (including aliases) to Command objects
NameToCommand = {}


# sugary utilities for building option lists
def AuthOptions(options=[]):
  """Return the standard auth options, appended to options if specified."""
  return options + [
    'no-auth-cache',
    'non-interactive',
    'password',
    'username',
    ]

def LogOptions(options=[]):
  """Return the standard log options, appended to options if specified."""
  return options + [
    'editor-cmd',
    'encoding',
    'file',
    'force-log',
    'message',
    ]


# WHINE(epg): They told me optparse is nice and extensible.  They
# lied.  It's not at all extensible, and quite painful.  I wish i'd
# done something else.
class OptionParser(optparse.OptionParser):
  def __init__(self):
    # Use 'resolve' so we can redefine svn options in GvnOptions.
    optparse.OptionParser.__init__(self, conflict_handler='resolve')
    self.canonical_options = {}
    # We do our own help processing.
    self.remove_option('--help')
    self.AddOptions(SvnOptions)
    self.AddOptions(GvnOptions)

  def AddOptions(self, options):
    defaults = {}
    for (name, option) in options.iteritems():
      self.canonical_options[name] = name
      if option.short is not None:
        self.canonical_options[option.short] = name

      # Build args and kwargs to pass to add_option, and defaults for
      # set_defaults.
      args = ['--' + name]
      if option.short is not None:
        args.append('-' + option.short)
      kwargs = {'action': 'callback', 'callback': self.Callback,
                'help': option.help}
      if option.has_argument:
        kwargs['type'] = 'str'
        # optparse defaults these to None.
      else:
        # optparse doesn't do anything with these if the user doesn't
        # specify, breaking the if options.some_option model
        # completely.  Don't ask me why.
        defaults[name.replace('-', '_')] = False
      self.add_option(*args, **kwargs)
    defaults['limit'] = '0'
    self.set_defaults(**defaults)

  def Callback(self, option, opt_str, value, parser):
    """Callback action to process each option.

    All i want is a list of options the user gave, but the only way to
    get it is to reimplement the option handling entirely.

    """

    opt_str = self.canonical_options[opt_str.lstrip('-')]

    # Maintain list of options the user gave.
    if opt_str != 'diag':
      try:
        gvn_options = parser.values.gvn_options
      except AttributeError:
        gvn_options = parser.values.gvn_options = []
      gvn_options.append(opt_str)

    # Set the option in the Values object, as optparse would.
    attr = opt_str.replace('-', '_')
    if value is None:
      setattr(parser.values, attr, True)
    else:
      setattr(parser.values, attr, value)

  def error(self, msg):
    """Turn optparse's attempts at sabotage into exceptions.

    optparse comes from the "why raise an exception like a sane
    library when you can just spew to stderr and then sys.exit?"
    school of thought.  How nice.

    """

    raise gvn.errors.BadOptions(msg)

  def parse_args(self, *args, **kwargs):
    (values, argv) = optparse.OptionParser.parse_args(self, *args, **kwargs)
    if not hasattr(values, 'gvn_options'):
      values.gvn_options = []
    # Post-process --extensions/-x option.
    # With multiple -x options, the last wins rather than appending;
    # this is how svn behaves.
    if values.extensions is None:
      values.extensions = []
    else:
      values.extensions = values.extensions.split()
    return (values, argv)


class TextDeltaProgressBase(object):
  """base class for text delta progress reporters"""

  def __init__(self, total, fp):
    """Initialize progress tracker.

    Arguments:
    total       -- total number of files to be transmitted
    fp          -- file-like object for output (must support write and flush)
    """

    #: total number of files to be transmitted
    self.file_total = total
    #: file-like object for output (must support write and flush)
    self.fp = fp

  def NewFile(self, path):
    """Begin reporting progress for new path."""
    pass

  def Finish(self):
    """Finish progress reporting."""
    pass

  def Update(self, progress, total):
    """Update progress; see svn_ra_progress_notify_func_t ."""
    pass


class TextDeltaProgressDots(TextDeltaProgressBase):
  """text delta progress reporter that just prints dots, like svn"""
  def __init__(self, total, fp):
    TextDeltaProgressBase.__init__(self, total, fp)
    self.fp.write('Transmitting file data ')
    self.fp.flush()

  def NewFile(self, path):
    self.fp.write('.')
    self.fp.flush()

  def Finish(self):
    self.fp.write('\n')


class TextDeltaProgressMeter(TextDeltaProgressBase):
  """text delta progress reporter that shows a progress meter"""

  def __init__(self, total, fp):
    TextDeltaProgressBase.__init__(self, total, fp)
    #: length of last line written to fp (used to clear it)
    self.last_line_len = 0
    #: number of files transmitted so far
    self.file_count = 0
    #: current average transmission rate
    self.average = 0
    #: current total bytes to be transferred
    self.total = 0
    #: current number of bytes transferred so far
    self.transmitted = 0
    #: progress from previous Update call
    self.last_progress = 0
    #: total bytes to be transferred from previous Update call
    self.last_total = 0
    #: time of last Update call
    self.last_time = int(time.time())
    #: actual progress to show, shared by Update and _Print
    self.progress = 0

  def NewFile(self, path):
    self.path = path
    self.file_count += 1
    self._Print()

  def Finish(self):
    self.fp.write('\r' + ' ' * self.last_line_len + '\r')

  def Update(self, progress, total):
    # TODO(epg): This can't help but be a little broken, simply due to
    # the way the ra layers report progress.  But maybe we can make it
    # better.  Anyway, here's how it works now:

    # ra-local: Reports no progress at all.  This is lame, as even
    # local commits can take a long time, if the change is big enough.

    # ra-neon: Reports progress with resetting progress and total at
    # various times.  Given that we get a proper total for text delta
    # transmission, with increasing progress values up through total,
    # I think the total is the total to be transmitted for a single
    # HTTP request.

    # ra-serf: Reports no progress at all.  The underlying serf
    # library doesn't even support progress notification.

    # ra-svn: Reports progress starting at 0 and increasing with each
    # call, never resetting; total is never available.  It actually
    # counts sends and receives in the same number.  We don't get a
    # new total for individual files or operations.

    # We really can't do anything to improve the ra-svn situation,
    # except to ra-svn itself.  Unlike neon and serf, it's fully
    # streamy, and does not know the size of text deltas in advance,
    # though, so we'll never be able to get totals out of it.  So,
    # it's probably best just to leave it alone.

    # For neon and serf, we print a useful meter for individual files
    # that are large enough.  But, for a commit of a few hundred small
    # files, the rate is wrong, never getting over 1K despite that we
    # are actually transmitting multiple M/s.

    # Based on Tortoise's progress meter.
    if progress >= self.last_progress and total == self.last_total:
      self.transmitted += progress - self.last_progress
    else:
      self.transmitted += progress
    self.last_progress = progress
    self.last_total = total

    now = int(time.time())
    if self.last_time < now:
      elapsed = now - self.last_time
      if elapsed == 0:
        elapsed = 1
      self.last_time = now
      self.average = self.transmitted / elapsed
      self.transmitted = 0
      self.total = total
      self.progress = progress
      self._Print()

  def _Print(self):
    """Print more to the meter."""

    # Based on Tortoise's progress meter.
    self.fp.write('\r' + ' ' * self.last_line_len)
    msg = ['[%d/%d]' % (self.file_count, self.file_total)]

    if self.average < 1024:
      msg.append('%dB/s' % (self.average,))
    elif self.average < 1024 * 1024:
      msg.append('%.1fK/s' % (self.average / 1024.0,))
    else:
      msg.append('%.1fM/s' % (self.average / 1024.0 / 1024.0,))

    if (self.total > 0):
      if self.total < 1024:
        msg.append('%d/%dB' % (self.progress, self.total))
      elif self.total < 1024 * 1024:
        msg.append('%d/%dK' % (self.progress / 1024, self.total / 1024))
      elif self.total < 1024 * 1024 * 1024:
        msg.append('%d/%dM' % (self.progress / 1024 / 1024,
                               self.total / 1024 / 1024))

    msg.append(self.path)
    msg = ' '.join(msg)
    self.last_line_len = len(msg)
    self.fp.write('\r' + msg)
    self.fp.flush()


class Context(object):
  """Context for a command: Project, WorkingCopy, Pool, callbacks, ...

  Just reference .project and .wc when you need them; they'll be
  automatically found based on the current directory (or operands; see
  next paragraph) or --project options, and setup with an auth baton.

  .wc_operands indicates whether the operands from the command line
  represent working copy paths.  If True (the default) and all
  operands are absolute paths, the deepest common of those paths is
  used to find the working copy, rather than the current directory.

  Also, if .wc_operands, the operands are converted to internal style.

  If Context knows about a working copy (because you've accessed .wc
  or accessing .project caused a working copy to be found), then .path
  is the current path within the working copy (e.g. /tmp/wc/src is
  current directory, /tmp/wc is the top of the working copy, so .path
  is 'src').'

  Call .Finish when finished with the object.

  """

  def __init__(self, options, operands):
    self.options = options
    self._operands = operands

    self._stderr = sys.stderr

    self._processed_operands = None
    self.wc_operands = True
    self._path = None
    self._project = self._project_config = self._wc = None
    self.pool = svn.core.Pool()
    self.config = gvn.config.Get(configdir=options.gvn_config_dir,
                                 svn_configdir=options.config_dir,
                                 pool=self.pool)

    # Initialize with --username optarg if specified.
    self._username = options.username

    #: current progress reporting object; only text deltas for now
    self.progress = None
    #: number of files we'll be transmitting
    self.txdelta_count = 0

    self._encoding = None

    self.UpdateConfigFromCommandLine()

  def UpdateConfigFromCommandLine(self):
    # Why should these be treated differently from encoding?  Maybe any
    # setting settable via command line and config file should be accessed
    # as ctx.foo rather than ctx.{config,options}.foo.
    if self.options.no_auth_cache:
      self.config.store_auth_creds = False
    if self.options.editor_cmd is not None:
      self.config.editor_command = self.options.editor_cmd

  def _GetEncoding(self):
    # The only real reason we treat this one differently from the above
    # two is that it needs validation, but only if we're trying to do an
    # operation that actually needs encoding.
    if self._encoding is None:
      if self.options.encoding is None:
        encoding = self.config.encoding
      else:
        encoding = self.options.encoding
      # It's a shame Python uses such a generic exception for such an
      # important error.  Translate it to something sane so we can
      # give the user a good error message.
      try:
        codecs.lookup(encoding)
      except LookupError:
        raise gvn.errors.UnknownEncoding(encoding)
      self._encoding = encoding
    return self._encoding
  encoding = property(_GetEncoding)

  def _GetProject(self):
    if self._project is None:
      self._FindProject()
    return self._project
  project = property(_GetProject)

  def _GetProjectConfig(self):
    if self._project_config is None:
      self._FindProject()
    return self._project_config
  project_config = property(_GetProjectConfig)

  def _GetOperands(self):
    if self.wc_operands:
      if not self._processed_operands:
        self._operands = [svn_path_internal_style(x, self.pool)
                          for x in self._operands]
        self._processed_operands = True
    return self._operands
  operands = property(_GetOperands)

  def _GuessWCPath(self):
    if self.wc_operands:
      # If operands are wc paths, guess from those if absolute.
      absolute = relative = False
      for i in self.operands:
        if os.path.isabs(i):
          absolute = True
        else:
          relative = True
      if absolute:
        if relative:
          raise gvn.errors.MixedPaths
        # Operands are all absolute; use common prefix.
        return gvn.util.CommonPrefix(self.operands)
      prefix = gvn.util.CommonPrefix(self.operands)
      if prefix != '':
        # Operands are all relative and prefix is more than just '';
        # use cwd/prefix.
        return '/'.join([svn_path_internal_style(os.getcwd(), self.pool),
                         prefix])
    # Operands are not wc paths or are relative and prefix is ''; use cwd.
    return svn_path_internal_style(os.getcwd(), self.pool)

  def _GetWC(self):
    if self._wc is None:
      (self._wc,
       self._path) = gvn.wc.FindWorkingCopy(self._GuessWCPath(),
                                           cancel_func=self.Cancel,
                                           notify_func=self.Notify,
                                           config=self.config,
                                           pool=self.pool)
      if self._project is None:
        self._project = self._wc.project
        self._project_config = self._wc.project_config
        self._SetRaCallbacks()
    return self._wc
  wc = property(_GetWC)
  path = property(lambda self: self._GetWC() and self._path,
doc="""WC-relative path that was used to find the wc.

    This is determined by what _GuessWCPath discovers.  If the
    operands are wc paths, then this is the longest common prefix of
    them (which may be '').  If not, this is ''.
    """)

  def Username(self, default):
    """Return user-input username if any, else default.

    If --username was specified, this returns that until overridden by
    a username entered at an auth prompt.  Whether --username was
    given or not, if the user entered a username at an auth prompt and
    authentication was successful, this returns that username.
    """
    if self._username is None:
      self._username = default
    return self._username

  def _SetUsername(self, username):
    """Set self._username to username ."""
    self._username = username

  def _SetRaCallbacks(self):
    auth_baton = gvn.svnauth.GetAuthBaton(
      self.Username(self.project_config.username),
      not self.options.non_interactive, self.config, self.pool,
      username_cb=self._SetUsername)
    self.project.repository.ra_callbacks.SetAuthBaton(auth_baton)
    self.project.repository.ra_callbacks.progress_func = self.Progress
    self.project.repository.ra_callbacks.cancel_func = self.Cancel

  def DisplayPath(self, path):
    """Return path suitable to display to the user from path.

    If self.wc.AbsolutePath(path) is a child of the current working
    directory, the display path is relative to that.  The display path
    is in local style.

    Arguments:
    path -- path relative to working copy top
    """
    try:
      result = gvn.util.RelativePath(svn_path_internal_style(os.getcwd(),
                                                             self.pool),
                                     self.wc.AbsolutePath(path))
    except gvn.errors.PathNotChild:
      result = path
    return svn_path_local_style(result, self.pool)

  def ValidateChangeName(self, cb):
    if cb.Exists():
      # Don't harass user if the changebranch already exists.
      return
    if self.options.force_change:
      # Bypass these checks with --force-change.
      return
    if cb.name == 'c':
      # A changebranch named c?  The user probably meant --cc.
      raise gvn.errors.ChangeIsC
    if os.path.exists(cb.name):
      # An changebranched named after a file?  Probably a mistake.
      raise gvn.errors.ChangeIsPath(cb.name)

  def Finish(self):
    """Finish working with the object.  Saves WorkingCopy state."""
    if self._wc is not None:
      self._wc.Save()

  def Cancel(self):
    """Implements svn_cancel_func_t."""
    # TODO(epg): yeah...
#     if should cancel:
#       return svn.core.SVN_ERR_CANCELLED
    return None

  def Progress(self, progress, total, pool):
    """Implements svn_ra_progress_notify_func_t."""
    if self.progress is not None:
      self.progress.Update(progress, total)

  def Notify(self, n, pool=None):
    # TODO(epg): OOPS, fool, these Notify needs to write to stdout!

    if self.options.quiet:
      return

    # TODO(epg): Notifying with string messages is lame, and sucks for
    # any gvn users but gvn itself.  Use appropriate notify actions,
    # inventing new ones if necesary.
    if isinstance(n, basestring):
      self._stderr.write(n)
      return

    # TODO(epg): Make this into a new svn_wc_notify_action_t value.
    if n == gvn.wc.notify_postfix_txdeltas_completed:
      # TODO(epg): Make .Finish setup an alarm to print a
      # "Finalizing... |/-|\" spinner until gvn.wc.notify_committed or
      # whatever, then we'd set .progress = None.
      self.progress.Finish()
      self.progress = None
      return

    if n.action == svn.wc.notify_commit_postfix_txdelta:
      if self.progress is None:
        if gvn.util.isatty(self._stderr):
          klass = TextDeltaProgressMeter
        else:
          klass = TextDeltaProgressDots
        self.progress = klass(self.txdelta_count, self._stderr)
      self.progress.NewFile(self.DisplayPath(n.path).encode(self.encoding))
      return

    if (n.action in [svn.wc.notify_commit_added,
                     svn.wc.notify_commit_modified]
        and n.kind == svn_node_file):
      # Count added or modified files for use by TextDeltaProgress
      # when we start transmitting text deltas.
      self.txdelta_count += 1

    action_messages = {
      svn.wc.notify_commit_added:    'Adding',
      svn.wc.notify_commit_deleted:  'Deleting',
      svn.wc.notify_commit_modified: 'Sending',
      svn.wc.notify_commit_replaced: 'Replacing',
      }
    self._stderr.write('%-14s %s\n' % (action_messages[n.action],
                                       self.DisplayPath(n.path).encode(self.encoding)))

  def _FindProject(self):
    pconfig = None
    if self.options.project is not None:
      try:
        # Start by trying a project file named self.options.project .
        pconfig = self.config.ProjectByName(self.options.project)
      except gvn.errors.InvalidProjectName:
        try:
          # Next try a project file for the URL self.options.project .
          pconfig = self.config.ProjectByURL(self.options.project)
        except gvn.errors.NoProject:
          # Finally, just create a ProjectConfig from whole cloth
          # using self.options.project as the URL.
          pconfig = gvn.config.ProjectConfig(self.Username(self.config.default_username),
                                             self.options.project)
      project = gvn.project.Project(self.Username(pconfig.username), pconfig.URL,
                                 self.config, self.pool)
    else:
      try:
        return self._GetWC()
      except gvn.errors.NotWC:
        # Construct ProjectConfig and Project objects for the default
        # project; if the user has no default project, the user sees
        # the no default project error.  epg thinks this block looks
        # kinda out of place in this file.  Seems we need some
        # function to return the Project and ProjectConfig.
        pconfig = self.config.ProjectDefault()
        project = gvn.project.Project(self.Username(pconfig.username), pconfig.URL,
                                      self.config, self.pool)

    self._project = project
    self._project_config = pconfig
    self._SetRaCallbacks()

  def GetDiffCallbacks(self, pool=None):
    """Return user's configured gvn.changebranch.DiffCallbacks implementation.
    """
    if pool is None:
      pool = self.pool
    if self.config.diff_command in [None, 'internal']:
      callbacks = gvn.changebranch.SvnDiffCallbacks(self.options.extensions,
                                                    sys.stdout, self.encoding,
                                                    pool)
    elif self.config.diff_command == 'tkdiff':
      callbacks = gvn.changebranch.TkDiffCallbacks(self.config.diff_command,
                                                   self.encoding)
    else:
      sys.stdout.flush()
      callbacks = gvn.changebranch.SvnDiffCallbacks(
        self.options.extensions, sys.stdout, self.encoding, pool,
        command=self.config.diff_command)
    return callbacks


def AddCommand(name, *args, **kwargs):
  """Add a new Command to the global list.

  Arguments:
  name    -- name of the new Command
  aliases -- optional list of aliases for the Command

  Any further arguments are passed to the Command constructor.

  """

  global CommandNames, NameToCommand
  CommandNames.append(name)
  aliases = kwargs.get('aliases', [])
  cmd = Command(name, *args, **kwargs)
  NameToCommand[name] = cmd
  # TODO(epg): Bleh, this command/aliases stuff needs reworking; we
  # handle it here and in the Command class...
  for i in aliases:
    NameToCommand[i] = cmd

def AssertValidSvnCommand(subcommand):
  for (_subcommand, aliases) in gvn.svncmdline.Subcommands.iteritems():
    if subcommand == _subcommand or subcommand in aliases:
      return
  raise gvn.errors.BadCommand(subcommand)

def RunSvnCommand(ctx, subcommand, operands, runner=gvn.svncmd.RunSvn):
  """Run an svn command.

  Arguments:
  ctx        -- cmdline.Context object
  subcommand -- svn subcommand to run
  operands   -- operands from OptionParser.parse_args
  runner     -- for unit testing; ignore
  """

  AssertValidSvnCommand(subcommand)

  # If any operands are working copies, svn will figure out what to
  # do; were we to leave this True, we'd mangle 'gvn log -v //' to
  # 'gvn log -v /', "short URL" processing would therefore never
  # occur, and we'd run completely the wrong svn command.
  ctx.wc_operands = False

  # TODO(epg): Consider having svncmd.RunSvn take separate subcommand
  # name, list of options and list of operands.  Let it worry about
  # inserting the command and -- protecting the operands.

  argv = [subcommand]

  # Insert all svn options; some may be invalid for this subcommand,
  # but that's for svn to worry about.
  for opt in ctx.options.gvn_options:
    if opt in GvnOptions or opt == 'config-dir':
      # Obviously we don't pass gvn options to svn; specially handle
      # --config-dir a few lines down.
      continue
    argv.append('--' + opt)
    if SvnOptions[opt].has_argument:
      argv.append(getattr(ctx.options, opt.replace('-', '_')))

  argv.append('--config-dir')
  argv.append(ctx.config.svn_config_dir)

  # Protect any operands starting with dashes.
  argv.append('--')

  # Now add any operands, massaging // pseudo-URLs into shape.
  for op in operands:
    try:
      argv.append(ctx.project.repository.ShortToLongURL(op))
    except (gvn.errors.NoProject, gvn.errors.NotShortURL):
      argv.append(op)

  return runner(argv)

def Run(options, argv):
  """Run a subcommand.

  Arguments:
  options -- options from OptionParser.parse_args
  argv    -- subcommand + operands

  """

  try:
    subcommand = argv.pop(0)
  except IndexError:
    raise gvn.errors.BadOperands("Type 'gvn help' for usage.")

  # TODO(epg): Remove this compat in next release.
  if subcommand == 'ack':
    sys.stderr.write("WARNING: 'ack' is deprecated; use 'approve' instead.\n")
    subcommand = 'approve'

  ctx = Context(options, argv)
  try:
    cmd = NameToCommand[subcommand]
  except KeyError:
    # We have no such subcommand; maybe svn does.
    return RunSvnCommand(ctx, subcommand, argv)

  try:
    return cmd(ctx)
  except:
    sys.stderr.write('gvn %s failed:\n' % (subcommand,))
    raise


class Command(object):
  # TODO(epg): options and options_help stick around in the object,
  # where callers may modify them; use None and build new list and
  # dict in __init__ as with aliases.
  def __init__(self, name, impl, helptext, options=[], options_help={},
               aliases=None):
    """Initialize Command from str, callable, str.

    Arguments:
    name         -- name
    impl         -- callable implementing the subcommand
    helptext     -- help text
    options      -- list of accepted options
    options_help -- map of option names to help text, overriding the
                    standard help text for an option
    aliases      -- optional list of aliases

    The order of the options is retained for the help text, so callers
    may control the ordering and grouping of option help.  The global
    options 'config-dir' and 'gvn-config-dir' are always added at the
    end; do not pass them in.

    """

    self.name = name
    self.impl = impl
    self.helptext = helptext
    self.options = options + [
      # --project is not here because some commands don't want that.
      'config-dir',
      'gvn-config-dir',
      ]
    self.options_help = options_help
    if aliases is None:
      self.aliases = []
    else:
      self.aliases = aliases

  def __call__(self, ctx):
    bad_options = set(ctx.options.gvn_options).difference(self.options)
    if len(bad_options) > 0:
      raise gvn.errors.BadOptions("Subcommand '%s' doesn't accept %s\n"
                                  "Type 'gvn help %s' for usage."
                            % (self.name, ' '.join(sorted(bad_options)),
                               self.name))
    result = self.impl(ctx)
    ctx.Finish()
    return result

  def Help(self):
    result = [self.helptext]
    result.append('Valid options:')

    for option_name in self.options:
      summary = ['--']
      summary.append(option_name)
      option = GvnOptions.get(option_name, SvnOptions.get(option_name))
      if option.short is not None:
        # Put the short option first.
        summary.insert(0, '-')
        summary.insert(1, option.short)
        # And bracket the long option.
        summary.insert(2, ' [')
        summary.append(']')

      if option.has_argument:
        # svn help has no clever meta-variables like we could have,
        # only 'arg'.  Do we want to be different?
        summary.append(' ARG')
      result.append('  %-22s: %s' % (''.join(summary),
                                     self.options_help.get(option_name,
                                                           option.help)))

    return '\n'.join(result)

def Help(operands):
  if len(operands) > 0 and operands[0] == 'help':
    operands.pop(0)

  if len(operands) == 0:
    commands = {}
    for (name, aliases) in gvn.svncmdline.Subcommands.iteritems():
      commands[name] = ("   %s" % (name,))
      if len(aliases) > 0:
        commands[name] = '%s (%s)' % (commands[name],
                                      ', '.join(a for a in aliases))
    for name in CommandNames:
      cmd = NameToCommand[name]
      if cmd.helptext is None:
        # skip alias objects
        continue
      commands[name] = ("   %s" % (name,))
      if len(cmd.aliases) > 0:
        commands[name] = '%s (%s)' % (commands[name],
                                      ', '.join(a for a in cmd.aliases))

    print 'usage: gvn help <subcommand>'
    print
    print 'Available subcommands:'
    for name in sorted(commands):
      print commands[name]

  else:
    for subcommand in operands:
      # TODO(epg): Remove this compat in next release.
      if subcommand == 'ack':
        sys.stderr.write("WARNING: 'ack' is deprecated; use 'approve' instead.\n")
        subcommand = 'approve'

      try:
        cmd = NameToCommand[subcommand]
      except KeyError:
        AssertValidSvnCommand(subcommand)
        gvn.svncmd.RunSvn(['help', subcommand])
      else:
        print cmd.Help()
        print

  return 0


def main(argv):
  diag = False

  try:
    parser = OptionParser()
    (options, argv) = parser.parse_args(argv[1:])

    diag = options.diag
    if diag:
      gvn.DiagLog = lambda f,*a: sys.stderr.write(f % a)
      gvn.svncmd.EnableCommandDebug()

    if options.version:
      print 'gvn %s\n' % (gvn.VERSION,)
      print "Using 'svn' at '%s'" % (gvn.SVN,)
      print
      gvn.svncmd.RunSvn(['--version'])
      return 0

    if options.help or len(argv) > 0 and argv[0] == 'help':
      return Help(argv)

    return Run(options, argv)

  except gvn.errors.Cmdline, e:
    if diag:
      traceback.print_exc()
    else:
      print >>sys.stderr, e
    print "Type 'gvn -h' for usage."
    return e.code
  except gvn.errors.User, e:
    if diag:
      traceback.print_exc()
      print >>sys.stderr, e.diag_message
    else:
      print >>sys.stderr, e
    return e.code
  except svn.core.SubversionException, e:
    if diag:
      traceback.print_exc()
    while e is not None:
      try:
        msg = e.message
        e = e.child
      except AttributeError:
        # old-style SubversionException, no children
        msg = e.args[0]
        e = None
      sys.stderr.write('gvn: %s\n' % (msg,))
    # svn just exits 1; we might want to capture the most common
    # ones and turn them into gvn.errors.User exceptions with their
    # own exit codes, e.g. catch out-of-dateness in gvn.commit.Drive
    # and raise gvn.errors.OutOfDate .
    return 1
  except gvn.errors.Internal, e:
    traceback.print_exc()
    return e.code
  except KeyboardInterrupt:
    return 1
  except IOError, e:
    if e.errno == EPIPE:
      if diag:
        traceback.print_exc()
      sys.stderr.write('gvn: %s\n' % (e,))
    else:
      traceback.print_exc()
    return gvn.errors.Internal.code

  except UnicodeError, e:
    e = gvn.errors.Encoding(e)
    if diag:
      traceback.print_exc()
      print >>sys.stderr, e.diag_message
    else:
      print >>sys.stderr, e
    return e.code

  except:
    traceback.print_exc()
    return gvn.errors.Internal.code
